11장. 배열과 슬라이스
지금까지 다룬 변수는 값 하나만 담는 그릇이었다. 이번 장부터는 여러 값을 묶어서 다루는 방법을 배운다.
Go 에는 비슷해 보이는 두 형제가 있다.
- 배열 (array) — 고정 길이
- 슬라이스 (slice) — 가변 길이
이론상 둘 다 “값을 줄 세워 담는다” 는 점은 같다. 하지만 실제로 Go 개발자가 거의 매일 쓰는 건 슬라이스 쪽이다. 배열은 그 슬라이스를 이해하기 위한 발판으로 짧게 짚고 넘어간다.
목표:
- 배열과 슬라이스가 어떻게 다른지 한 문장으로 말하기
- 슬라이스를 자유롭게 만들고, 늘리고, 자르기
len과cap의 차이 이해하기append,copy,for range를 손에 익히기
11.1 배열
배열은 같은 타입의 값을 정해진 개수만큼 한 줄로 담는 자료형이다.
var a [5]int
이 한 줄은 “정수 5개를 담는 그릇 a” 를 만든다.
값을 따로 넣어 주지 않았기 때문에
다섯 칸 모두 정수의 제로값 0 으로 채워진다.
인덱스로 접근
각 칸은 0번부터 시작하는 인덱스로 가리킨다.
a[0] = 10
a[4] = 99
fmt.Println(a) // [10 0 0 0 99]
fmt.Println(a[0]) // 10
범위 밖 인덱스를 쓰면 컴파일 또는 런타임 에러가 난다.
fmt.Println(a[5]) // panic: index out of range
길이가 타입의 일부다
여기가 배열의 가장 중요한 특징이자 함정이다.
var a [5]int
var b [10]int
a 와 b 는 둘 다 정수 배열이지만,
타입이 서로 다르다.
a의 타입은[5]intb의 타입은[10]int
대입도 불가능하다.
a = b // 컴파일 에러: cannot use b (type [10]int) as type [5]int
함수에 넘길 때도 길이가 정확히 맞아야 한다. “정수 배열을 받는 함수” 가 아니라 “길이 5짜리 정수 배열을 받는 함수” 가 되는 셈이다.
이게 너무 빡빡하기 때문에 실무에서 배열을 직접 쓰는 일은 거의 없다.
배열 리터럴
값을 미리 정해서 만들 수도 있다.
nums := [3]int{1, 2, 3}
길이를 일일이 세기 귀찮다면 ... 을 써서 컴파일러에게 맡긴다.
nums := [...]int{10, 20, 30, 40}
fmt.Println(len(nums)) // 4
[...]int 는 “길이는 알아서 세 줘” 라는 뜻이다.
결과 타입은 여전히 [4]int 처럼 길이가 박힌 배열이다.
배열은 값이다
마지막으로 알아 둘 점. 배열을 다른 변수에 대입하면 통째로 복사된다.
a := [3]int{1, 2, 3}
b := a
b[0] = 999
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [999 2 3]
b 를 바꿔도 a 는 그대로다.
이 동작은 슬라이스와 다르므로 잘 기억해 둔다.
배열에 대해선 이 정도면 충분하다. 이제 Go 의 주력 자료구조인 슬라이스로 넘어간다.
11.2 슬라이스: Go 의 주력 자료구조
슬라이스는 길이가 자유롭게 변하는 시퀀스다. 다른 언어의 동적 배열, ArrayList, 리스트와 비슷하다.
선언과 초기화
가장 흔한 세 가지 방법.
// 1. 리터럴로 바로 만들기
nums := []int{1, 2, 3}
// 2. make 로 길이만 지정해서 만들기
zeros := make([]int, 5)
// → [0 0 0 0 0]
// 3. make 로 길이와 용량 함께 지정
buf := make([]int, 0, 10)
// 길이 0, 용량 10
배열과 어떻게 다른지 한눈에 보이는 차이는 대괄호 안에 숫자가 없다는 점이다.
[5]int // 배열: 길이 5
[]int // 슬라이스: 길이 정해지지 않음
슬라이스의 내부 구조
슬라이스는 마법이 아니다. 내부적으로는 세 개의 값을 묶은 작은 구조다.
| 필드 | 의미 |
|---|---|
| 포인터 | 실제 데이터가 들어 있는 배열의 시작 위치 |
| 길이 (len) | 지금 보이는 원소의 개수 |
| 용량 (cap) | 다시 할당 없이 늘릴 수 있는 최대 개수 |
슬라이스 ──▶ [ * ] [ 3 ] [ 5 ]
│
▼
[ 1, 2, 3, _, _ ] ← 실제 배열
즉, 슬라이스 그 자체는 “데이터를 가리키는 작은 손잡이” 다. 값을 담고 있는 진짜 배열은 따로 있다.
이 모델만 머릿속에 그려 두면
앞으로 나오는 len, cap, append, 슬라이싱이 자연스럽게 이해된다.
11.3 len 과 cap
내장 함수 두 개로 슬라이스의 상태를 들여다본다.
s := make([]int, 3, 10)
fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 10
len(s)— 지금 사용 중인 원소 수cap(s)— 새 배열을 만들지 않고 담을 수 있는 최대치
처음엔 헷갈리기 쉬우니 비유로 본다.
len은 가방 안에 든 책 권수cap은 가방 안에 들어갈 수 있는 최대 권수
가방이 꽉 차기 전까진 책을 더 넣을 수 있다.
넘치면 더 큰 가방으로 옮겨야 한다.
이 “옮기는 동작” 이 다음 절의 append 에서 일어난다.
| 만든 방식 | len | cap |
|---|---|---|
[]int{1,2,3} | 3 | 3 |
make([]int, 5) | 5 | 5 |
make([]int, 0, 10) | 0 | 10 |
make([]int, 3, 10) | 3 | 10 |
11.4 append: 원소 추가
슬라이스에 새 값을 더할 땐 내장 함수 append 를 쓴다.
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s) // [1 2 3 4]
겉으로는 단순하지만, 내부에서는 두 가지 분기가 있다.
- 용량(
cap)에 여유가 있다 → 같은 배열의 빈 칸에 그냥 채운다 - 용량이 부족하다 → 더 큰 배열을 새로 만들고 기존 값을 복사한 뒤 추가한다
그래서 append 는 반드시 반환값을 다시 받아야 한다.
// 옳은 사용
s = append(s, 4)
// 잘못된 사용 — 새 배열이 만들어졌다면 s 는 그대로 옛 데이터를 본다
append(s, 4) // 컴파일러가 "값을 안 쓴다" 고 경고하기도 한다
여러 개를 한 번에
append 는 원소를 여러 개 받을 수 있다.
s := []int{1}
s = append(s, 2, 3, 4)
fmt.Println(s) // [1 2 3 4]
슬라이스끼리 합치기
다른 슬라이스를 통째로 붙일 땐 ... 을 붙인다.
a := []int{1, 2, 3}
b := []int{4, 5, 6}
c := append(a, b...)
fmt.Println(c) // [1 2 3 4 5 6]
b... 는 “b 의 원소들을 풀어서 인자로 넘긴다” 는 의미다.
9장의 가변 인자와 같은 문법이다.
append 의 성능 감각
append 가 새 배열을 만드는 일은 비용이 크다.
하지만 Go 는 한 번 늘릴 때 용량을 두 배 가까이 잡아 둔다.
그래서 평균적으로 보면 매우 빠르다.
미리 크기를 알 수 있다면
make([]T, 0, n)으로 용량을 잡아 두는 것이 좋다. 자세한 메모리 이야기는 26장에서 다룬다.
11.5 슬라이싱
이미 있는 슬라이스(또는 배열)에서 “일부 구간만 잘라낸 슬라이스” 를 만들 수 있다.
s := []int{10, 20, 30, 40, 50}
mid := s[1:4]
fmt.Println(mid) // [20 30 40]
s[1:4] 는 “인덱스 1부터 4 직전까지” 를 의미한다.
끝쪽 4는 포함되지 않는 점에 주의한다.
한쪽을 생략
양쪽 끝은 생략할 수 있다.
s[:3] // 처음부터 인덱스 3 직전까지
s[2:] // 인덱스 2부터 끝까지
s[:] // 전체
| 표현 | 결과 |
|---|---|
s[1:4] | [20 30 40] |
s[:3] | [10 20 30] |
s[2:] | [30 40 50] |
s[:] | [10 20 30 40 50] |
슬라이싱은 복사가 아니다
이게 슬라이스에서 가장 잘 모르고 지나가는 부분이다. 잘라낸 슬라이스는 원본과 같은 배열을 공유한다.
s := []int{1, 2, 3, 4, 5}
m := s[1:4]
m[0] = 999
fmt.Println(s) // [1 999 3 4 5]
fmt.Println(m) // [999 3 4]
m 을 건드렸을 뿐인데 s 까지 같이 바뀐 모습이 보인다.
손잡이만 두 개일 뿐, 안쪽 배열은 한 덩어리이기 때문이다.
이 동작은 강력하지만 메모리 측면에선 함정이 되기도 한다. “부분 슬라이스가 거대한 원본 배열을 붙잡고 놓아주지 않는” 패턴이 대표적이다. 자세한 이야기와
copy로 끊어내는 기법은 26장에서 다룬다.
11.6 copy
두 슬라이스 사이에서 값을 복사할 땐 내장 함수 copy 를 쓴다.
src := []int{1, 2, 3}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(dst) // [1 2 3]
fmt.Println(n) // 3
copy(dst, src) 는
- dst 에 src 의 값을 복사한다
- 실제로 복사된 개수를 반환한다
짧은 쪽 길이만큼만
두 슬라이스의 길이가 다르면, 짧은 쪽 길이만큼만 복사된다.
dst := make([]int, 2)
src := []int{1, 2, 3, 4, 5}
n := copy(dst, src)
fmt.Println(dst) // [1 2]
fmt.Println(n) // 2
이 동작 덕분에 버퍼 크기를 넘는 데이터를 잘라 받기 편하다.
append 와의 차이
| 도구 | 하는 일 |
|---|---|
append | 슬라이스를 늘리며 값을 추가한다 |
copy | 기존 슬라이스의 칸에 값을 덮어쓴다 |
copy 는 dst 의 길이를 늘려 주지 않는다.
이미 있는 칸을 채울 뿐이다.
“빈 슬라이스에 copy 했더니 아무 일도 안 일어났다” 는
초보의 단골 실수가 여기서 나온다.
var dst []int
src := []int{1, 2, 3}
copy(dst, src)
fmt.Println(dst) // [] ← dst 의 길이가 0이라 아무것도 못 들어간다
이런 경우엔 append 를 써야 한다.
11.7 슬라이스 순회 (for range)
8장에서 살짝 본 for range 가 이제 본격적으로 빛난다.
인덱스와 값 둘 다
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Println(i, v)
}
// 출력:
// 0 10
// 1 20
// 2 30
i 는 인덱스, v 는 그 인덱스의 값이다.
인덱스만 쓰고 싶을 때
값을 안 쓰면 그냥 두 번째 변수를 빼면 된다.
for i := range nums {
fmt.Println(i)
}
값만 쓰고 싶을 때
인덱스를 안 쓸 땐 자리 표시자 _ 를 쓴다.
for _, v := range nums {
fmt.Println(v)
}
_ 는 “값을 받기는 하지만 쓰지 않겠다” 는 명시적 표시다.
Go 는 안 쓰는 변수를 컴파일 에러로 막기 때문에
이런 자리 표시자가 자주 등장한다.
range 가 주는 v 는 복사본이다
조금 미묘한 포인트.
nums := []int{1, 2, 3}
for _, v := range nums {
v = v * 10 // 원본은 안 바뀐다
}
fmt.Println(nums) // [1 2 3]
v 는 원소의 복사본이다.
원본을 바꾸려면 인덱스로 직접 접근해야 한다.
for i := range nums {
nums[i] *= 10
}
fmt.Println(nums) // [10 20 30]
11.8 nil 슬라이스
선언만 하고 초기화하지 않은 슬라이스는 어떻게 될까?
var s []int
fmt.Println(s == nil) // true
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0
이 상태를 nil 슬라이스라고 한다.
- 값은
nil - 길이도 0, 용량도 0
- 안에 가리키는 배열이 아직 없는 상태
여기서 흥미로운 점.
nil 슬라이스에 append 해도 된다
var s []int
s = append(s, 1)
s = append(s, 2)
fmt.Println(s) // [1 2]
append 는 nil 슬라이스를 받으면
새 배열을 알아서 만들어 첫 원소를 넣는다.
다른 언어처럼 “비어 있는 리스트인지 먼저 확인” 같은
방어 코드가 거의 필요하지 않다.
nil 슬라이스 vs 빈 슬라이스
비슷하지만 미묘하게 다르다.
var a []int // nil 슬라이스
b := []int{} // 빈 슬라이스 (nil 아님)
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(len(a)) // 0
fmt.Println(len(b)) // 0
대부분의 코드에서는 둘이 똑같이 동작한다. 실용적으로는 “둘 다 길이 0인 슬라이스” 로 묶어서 이해하면 된다.
12장에서 다룰 맵의 nil 동작과는 다르다. 맵의 nil 은 함정이 많다.
11.9 정리
이 장에서 살펴본 내용:
- 배열은 길이가 타입에 박힌 고정 시퀀스다
- 잘 안 쓰이지만 슬라이스 이해의 발판
- 슬라이스는 (포인터, 길이, 용량) 세 값을 가진 손잡이다
len은 현재 길이,cap은 재할당 없이 담을 수 있는 최대치append는 반환값을 반드시 다시 받아야 한다- 슬라이싱은 새 배열을 만드는 게 아니라 원본을 공유한다
copy는 짧은 쪽 길이만큼만 복사한다for range로 인덱스, 값, 또는 둘 다 받을 수 있다- nil 슬라이스도
append가 가능하다
슬라이스는 Go 코드의 기본 단위라고 봐도 좋다. 앞으로 거의 모든 챕터에서 다시 만나게 된다.
다음 장에서는 또 다른 핵심 자료구조인 맵을 다룬다. 키로 값을 찾는 자료구조가 어떻게 생겼는지 살펴본다.